Skip to content

feat: Update scale event with custom recognizer#3782

Open
stilnat wants to merge 68 commits intoflame-engine:mainfrom
stilnat:recognizer-v2
Open

feat: Update scale event with custom recognizer#3782
stilnat wants to merge 68 commits intoflame-engine:mainfrom
stilnat:recognizer-v2

Conversation

@stilnat
Copy link
Contributor

@stilnat stilnat commented Nov 24, 2025

Description

Currently flame handle scale input events in a very hacky way, by getting the drag gesture recognizer data and recomputing
the data for the scale gesture. This has multiple issues :

  • Recompute already computed stuff like rotation and scale factor -> inefficient
  • Make the code difficult to understand, had to introduce hard dependencies between scale and drag dispatcher
  • Limits the allowed multi drag gestures : those looking like scale gestures simply would not register as such but only as scale

This PR aims to fix all those issues by introducing a new gesture recognizer, which is basically just a mix of ScaleGestureRecognizer and immediateMultiDragGestureRecognizer, allowing pointers to be used for both gestures
without competing. I used the existing flutter code to write it.

I modified a bit ScaleCallbacks and DragCallbacks, so they use their original dispatcher if there is only one type
of them (it's a bit more efficient), and so they upgrade to using MultiDragScaleDispatcher if both mixins are mounted.
Transition between the two is smooth as the old dispatcher wait for all gestures it started to finish before removing itself.

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • I have updated/added relevant examples in examples or docs.

I wonder if gesture_input.md should be updated

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Related Issues

I believe it Closes #2635

Copy link
Member

@spydon spydon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, just a few comments

stilnat and others added 9 commits November 27, 2025 14:53
Co-authored-by: Lukas Klingsbo <lukas.klingsbo@gmail.com>
- Add removal guard in ScaleDispatcher.handleScaleStart to reject new
  gestures after markForRemoval()
- Add missing unregisterKey call in ScaleDispatcher.onRemove
- Fix comment typos referencing wrong class name in scale_drag_dispatcher
- Remove duplicate focal point computation in recognizer _update()
- Convert test helper methods to proper extension methods and fix call-sites
Cover markForRemoval guard, deferred removal after active gestures,
unregisterKey re-creation, and ScaleDispatcher/MultiDragDispatcher
upgrade to MultiDragScaleDispatcher.
Interactive example that lets users spawn drag-only, scale-only, or
combined components at runtime using buttons. Useful for verifying
that mixing different interaction types works seamlessly.
Add Scale Events to the inputs TOC. Document the isDragged/isScaling
properties, combining both mixins on a single component, and dynamic
addition of components with different callback types at runtime.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors how Flame handles simultaneous drag + scale gestures by introducing a dedicated combined gesture recognizer/dispatcher, replacing the previous “recompute scale from drag” approach and improving interoperability between DragCallbacks and ScaleCallbacks.

Changes:

  • Added MultiDragScaleGestureRecognizer + MultiDragScaleDispatcher to recognize/dispatch drag and scale gestures together.
  • Updated DragCallbacks / ScaleCallbacks mounting logic to dynamically upgrade dispatchers (and added dispatcher lifecycle tests).
  • Expanded docs and examples to cover combined and dynamically-added drag/scale components.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/flame/lib/src/events/multi_drag_scale_recognizer.dart New combined recognizer implementation for multi-drag + scale.
packages/flame/lib/src/events/flame_game_mixins/scale_drag_dispatcher.dart New dispatcher wiring combined recognizer into Flame’s component event system.
packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart Dispatcher lifecycle changes (mark-for-removal), and removed stream APIs.
packages/flame/lib/src/events/flame_game_mixins/scale_dispatcher.dart Removed old “scale-from-drag” recomputation and added mark-for-removal lifecycle.
packages/flame/lib/src/events/component_mixins/drag_callbacks.dart Dispatcher selection/upgrade logic updates for drag/scale coexistence.
packages/flame/lib/src/events/component_mixins/scale_callbacks.dart Dispatcher selection/upgrade logic updates for drag/scale coexistence.
packages/flame/lib/events.dart Exports the new MultiDragScaleDispatcher.
packages/flame/test/events/component_mixins/input_test_helper.dart New shared test helpers for drag/scale gesture simulation and counters.
packages/flame/test/events/component_mixins/drag_callbacks_test.dart Updated to use shared helpers + added lifecycle tests.
packages/flame/test/events/component_mixins/scale_callbacks_test.dart Updated to use shared helpers + added lifecycle tests.
packages/flame/test/events/component_mixins/scale_drag_callbacks_test.dart New test suite validating combined drag+scale behavior and upgrades.
doc/flame/inputs/drag_events.md Documentation for isDragged and combining with scale callbacks.
doc/flame/inputs/scale_events.md Documentation for isScaling and combining with drag callbacks.
doc/flame/inputs/inputs.md Adds Scale Events to the input docs index.
examples/lib/stories/input/scale_drag_example.dart Updated example behavior for combined gestures.
examples/lib/stories/input/dynamic_scale_drag_example.dart New example demonstrating runtime addition/removal of components.
examples/lib/stories/input/input.dart Registers the new example in the story list.
Comments suppressed due to low confidence (2)

packages/flame/lib/src/events/flame_game_mixins/multi_drag_dispatcher.dart:172

  • markForRemoval() currently only prevents dispatching in handleDragStart, but the ImmediateMultiDragGestureRecognizer will still compete in Flutter’s gesture arena and may win new pointers while _shouldBeRemoved is true (causing new drags to be dropped and also blocking the new MultiDragScaleGestureRecognizer). Consider gating the recognizer’s onStart callback itself (return null when _shouldBeRemoved), or otherwise ensure the old recognizer no longer claims new pointers once marked for removal.
    game.gestureDetectors.add<ImmediateMultiDragGestureRecognizer>(
      ImmediateMultiDragGestureRecognizer.new,
      (ImmediateMultiDragGestureRecognizer instance) {
        instance.onStart = (Offset point) => FlameDragAdapter(this, point);
      },
    );

examples/lib/stories/input/scale_drag_example.dart:75

  • In update(dt), camera rotation/zoom increments were changed to fixed per-frame deltas (+= 0.001) instead of being scaled by dt. This makes the example frame-rate dependent; consider restoring dt-based updates (or document that the values are intentionally per-frame).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

spydon and others added 7 commits March 3, 2026 10:15
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…izer

The property was declared but never used in the recognizer logic.
ImmediateMultiDragGestureRecognizer, which this recognizer is modeled
after, does not have this property either.
All needed types are already provided by flutter/gestures.dart.
The rotation and zoom increments were accidentally changed to fixed
per-frame deltas, making them frame-rate dependent. Restore the
original 0.1 * dt scaling.
Prevent creating orphaned FlameDragAdapter instances when the dispatcher
is marked for removal during a dispatcher upgrade. Returns null from the
recognizer's onStart callback so the pointer is cleanly rejected rather
than accepted and silently dropped.
Document on both MultiDragDispatcher and ScaleDispatcher that new
gestures are silently dropped during the overlap window while active
gestures from the old dispatcher are still completing.
During 2+ pointer scale gestures, drag updates now follow the focal
point (center between fingers) instead of an individual pointer.
Also fixes inverted drag in the example when a component is rotated.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

spydon added 3 commits March 3, 2026 14:31
_delta was computed from _localFocalPoint (after event.transform) but
used as ScaleUpdateDetails.focalPointDelta and DragUpdateDetails.delta,
both of which pair with global positions. Compute from global
_currentFocalPoint instead to keep coordinate spaces consistent.
Replace StackOverflow-sourced ZoomTesting.timedZoomFrom with an
original implementation to resolve license incompatibility with the
MIT-licensed repo.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 21 to 33
/// **MultiDragDispatcher** facilitates dispatching of drag events to the
/// [DragCallbacks] components in the component tree. It will be attached to
/// the [FlameGame] instance automatically whenever any [DragCallbacks]
/// components are mounted into the component tree.
class MultiDragDispatcher extends Component implements MultiDragListener {
/// The record of all components currently being touched.
final Set<TaggedComponent<DragCallbacks>> _records = {};

final _dragUpdateController = StreamController<DragUpdateEvent>.broadcast(
sync: true,
);

Stream<DragUpdateEvent> get onUpdate => _dragUpdateController.stream;

final _dragStartController = StreamController<DragStartEvent>.broadcast(
sync: true,
);

Stream<DragStartEvent> get onStart => _dragStartController.stream;

final _dragEndController = StreamController<DragEndEvent>.broadcast(
sync: true,
);

Stream<DragEndEvent> get onEnd => _dragEndController.stream;

final _dragCancelController = StreamController<DragCancelEvent>.broadcast(
sync: true,
);

Stream<DragCancelEvent> get onCancel => _dragCancelController.stream;

FlameGame get game => parent! as FlameGame;

bool _shouldBeRemoved = false;

/// Called when the user initiates a drag gesture, for example by touching the
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the previously exposed drag event streams (onStart/onUpdate/onEnd/onCancel) from MultiDragDispatcher. Since MultiDragDispatcher is exported from package:flame/events.dart, this is a public API change and may be breaking for downstream users. If the streams are still intended as part of the public surface, consider keeping them (or deprecating first); otherwise consider making the class/internal members non-public or explicitly documenting the breaking change (and updating the PR title/metadata accordingly).

Copilot uses AI. Check for mistakes.
Comment on lines +132 to +145
// Check if we should accept all gestures based on scale threshold
if (_pointers.length >= 2 && !_scaleGestureActive) {
_checkScaleGestureThreshold();
}

// Start scale gesture if we now have 2+ pointers
if (!_scaleGestureActive && _pointers.length >= 2) {
_scaleGestureActive = true;
_initialFocalPoint = _currentFocalPoint;
_initialSpan = _currentSpan;
_initialHorizontalSpan = _currentHorizontalSpan;
_initialVerticalSpan = _currentVerticalSpan;
_initialLine = _currentLine;
_initialScaleEventTimestamp = event.timeStamp;
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_checkScaleGestureThreshold() is called before _initialSpan/_initial*Span are initialized for the 2+ pointer case. Since _initialSpan is set when the first pointer is added (typically 0), spanDelta = (_currentSpan - _initialSpan) becomes large as soon as the 2nd pointer moves, causing the arena to be resolved/accepted immediately and effectively bypassing scaleThreshold/slop gating. Consider initializing the “initial” span/focal data when the 2nd pointer is added (or when pointerCount first becomes 2), or move the threshold check to after the block that sets _initialSpan for the scale gesture.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ScaleDetector doesn't work when a Component with DragCallbacks is added

3 participants